home *** CD-ROM | disk | FTP | other *** search
/ Mac Easy 2010 May / Mac Life Ubuntu.iso / casper / filesystem.squashfs / usr / lib / totem / plugins / bbc / contentview.py < prev    next >
Encoding:
Python Source  |  2009-04-14  |  39.7 KB  |  1,020 lines

  1. #!/usr/bin/python
  2. # coding=UTF-8
  3. #
  4. # Copyright (C) 2008 Tim-Philipp M√ºller <tim.muller@collabora.co.uk>
  5. # Copyright (C) 2008 Canonical Ltd.
  6. #
  7. # This program is free software; you can redistribute it and/or modify
  8. # it under the terms of the GNU General Public License as published by
  9. # the Free Software Foundation; either version 2 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # This program is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  15. # GNU General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU General Public License
  18. # along with this program; if not, write to the Free Software
  19. # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301  USA.
  20. #
  21. # The Totem project hereby grant permission for non-gpl compatible GStreamer
  22. # plugins to be used and distributed together with GStreamer and Totem. This
  23. # permission are above and beyond the permissions granted by the GPL license
  24. # Totem is covered by.
  25. #
  26. # See license_change file for details.
  27. #
  28. # TODO:
  29. #  - clean up code: mixed studlyCaps and foo_bar; mixed callbacks and signals
  30.  
  31. import gobject
  32. gobject.threads_init()
  33. import glib
  34. import gio
  35. import pygst
  36. pygst.require ("0.10")
  37. import gst
  38. import gtk
  39. import pango
  40.  
  41. import os
  42. import dircache
  43. import errno
  44. import random
  45. import time
  46. import thread
  47.  
  48. from rdflib.Graph import ConjunctiveGraph
  49. from rdflib import Namespace
  50. from rdflib import RDF
  51.  
  52. from xdg import BaseDirectory
  53.  
  54. import installablecodecs
  55. import genres
  56.  
  57. '''
  58. Define namespaces we will be using globally
  59. '''
  60. DC = Namespace('http://purl.org/dc/elements/1.1/')
  61. PO = Namespace('http://purl.org/ontology/po/')
  62. OWL = Namespace('http://www.w3.org/2002/07/owl#')
  63. FOAF = Namespace('http://xmlns.com/foaf/0.1/')
  64. PLAY = Namespace('http://uriplay.org/elements/')
  65.  
  66.  
  67. '''
  68. Global codec cache singleton
  69. '''
  70. codec_cache = None
  71.  
  72. '''
  73. Container/Audio/Video codec mappings - global for readability
  74. '''
  75.  
  76. # FIXME: Real Media formats
  77. container_map = { 'application/ogg' : 'application/ogg',
  78.                   'audio/ogg' : 'application/ogg',
  79.                   'video/ogg' : 'application/ogg',
  80.                   'video/x-ms-asf' : 'video/x-ms-asf',
  81.                   'audio/x-ms-asf' : 'video/x-ms-asf',
  82.                   'audio/mp3' : 'audio/mpeg, mpegversion=(int)1, layer=(int)3',
  83.                   'audio/mp4' : 'audio/x-m4a',
  84.                   'audio/mpeg' : 'audio/mpeg, mpegversion=(int)1',
  85.                   'video/x-flv' : 'video/x-flv',
  86.                   'video/3gpp' : 'application/x-3gp',
  87.                   'application/x-3gp' : 'application/x-3gp',
  88.                   'audio/x-matroska' : 'video/x-matroska',
  89.                   'video/x-matroska' : 'video/x-matroska',
  90.                   'video/mp4' : 'video/quicktime',
  91.                   'video/mpeg' : 'video/mpeg, mpegversion=(int)1, ' +
  92.                                  ' systemstream=(boolean)true;   ' + 
  93.                                  'video/mpeg, mpegversion=(int)2, ' +
  94.                                   ' systemstream=(boolean)true',
  95.                   'video/mpeg2' : 'video/mpeg, mpegversion=(int)2,' +
  96.                                   ' systemstream=(boolean)false',
  97.                   'video/mp2t' : 'video/mpegts',
  98.                   'video/mpegts' : 'video/mpegts' }
  99.  
  100. # FIXME: do we need both parsed=true and parsed=false for mp3?
  101. audio_map = { 'audio/mp1' : 'audio/mpeg, mpegversion=(int)1, layer=(int)1',
  102.               'audio/mp2' : 'audio/mpeg, mpegversion=(int)1, layer=(int)2',
  103.               'audio/mp3' : 'audio/mpeg, mpegversion=(int)1, layer=(int)3',
  104.               'audio/mp4' : 'audio/mpeg, mpegversion=(int)2; ' +
  105.                             'audio/mpeg, mpegversion=(int)4',
  106.               'audio/mpeg' : 'audio/mpeg, mpegversion=(int)1, layer=(int)3',
  107.               'audio/x-wma' : 'audio/x-wma, wmaversion=(int)1; ' +
  108.                               'audio/x-wma, wmaversion=(int)2',
  109.               'audio/x-wmv' : 'audio/x-wma, wmaversion=(int)1; ' +
  110.                               'audio/x-wma, wmaversion=(int)2',
  111.               'audio/x-ms-wma' : 'audio/x-wma, wmaversion=(int)1; ' +
  112.                                  'audio/x-wma, wmaversion=(int)2',
  113.               'audio/x-ms-wmv' : 'audio/x-wma, wmaversion=(int)1; ' +
  114.                                  'audio/x-wma, wmaversion=(int)2',
  115.               'audio/vorbis' : 'audio/x-vorbis' }
  116.  
  117. # FIXME: video/x-ms-wmv: ask if this refers to a particular wmv version or if it can be any version/profile
  118. # BBC regard video/x-svq as equivalent to video/x-flash-video, so we just
  119. # treat them all the same here and require all of them in this case
  120. video_map = { 'video/x-vp6' : 'video/x-vp6',
  121.               'video/x-flash-video' : 'video/x-svq, svqversion=(int)1; ' +
  122.                                       'video/x-svq, svqversion=(int)3; ' +
  123.                                       'video/x-flash-video, flvversion=(int)1',
  124.               'video/H263-200' : 'video/x-svq, svqversion=(int)1; ' +
  125.                                  'video/x-flash-video, flvversion=(int)1',
  126.               'video/x-svq' : 'video/x-svq, svqversion=(int)1; ' +
  127.                               'video/x-svq, svqversion=(int)3; ' +
  128.                               'video/x-flash-video, flvversion=(int)1',
  129.               'video/H264' : 'video/x-h264',
  130.               'video/mpeg' : 'video/mpeg, mpegversion=(int)1, ' +
  131.                              ' systemstream=(boolean)false;   ' + 
  132.                              'video/mpeg, mpegversion=(int)2, ' +
  133.                               ' systemstream=(boolean)false',
  134.               'video/mpeg1' : 'video/mpeg, mpegversion=(int)1, ' +
  135.                               ' systemstream=(boolean)false',
  136.               'video/mpeg2' : 'video/mpeg, mpegversion=(int)2, ' +
  137.                               ' systemstream=(boolean)false',
  138.               'video/x-dirac' : 'video/x-dirac',
  139.               'video/x-wmv' : 'video/x-wmv, wmvversion=(int)1; ' +
  140.                               'video/x-wmv, wmvversion=(int)2; ' +
  141.                               'video/x-wmv, wmvversion=(int)3',
  142.               'video/x-ms-wmv' : 'video/x-wmv, wmvversion=(int)1; ' +
  143.                                  'video/x-wmv, wmvversion=(int)2; ' +
  144.                                  'video/x-wmv, wmvversion=(int)3' }
  145.  
  146. ###############################################################################
  147.  
  148. '''
  149. CodecCache: keeps track of what is currently installed and what we might be
  150.             able to install; caches things internally in a dict so we don't
  151.             have to do expensive checks more often than necessary; do not
  152.             cache results elsewhere and make sure to listen to the 'loaded'
  153.             signal and refilter any content once the database of installable
  154.             and installed codecs is loaded (methods may just return False if
  155.             the database hasn't been loaded yet)
  156. '''
  157. class CodecCache(gobject.GObject):
  158.     __slots__ = [ 'codec_cache', 'installable_codecs' ]
  159.  
  160.     __gsignals__ = dict(loaded=(gobject.SIGNAL_RUN_LAST, None, ()))
  161.  
  162.     def __init__(self):
  163.         gobject.GObject.__init__ (self)
  164.         self.codec_cache = { }
  165.         self.installable_codecs = None
  166.  
  167.     def reload_async(self):
  168.         gst.log('starting codec cache loading')
  169.         thread.start_new_thread(self._loading_thread, ())
  170.  
  171.     def _loading_thread(self):
  172.         ''' idle callback to marshal result back into the main thread '''
  173.         def _loading_done_idle_cb(res):
  174.             gst.log('codec cache loaded (%d elements)' % (len(res)))
  175.             self.installable_codecs = res
  176.             self.emit('loaded')
  177.             return False # don't want to be called again
  178.  
  179.         gst.log('in codec cache loading thread')
  180.         # the following can take quite a few seconds on machines with very
  181.         # low cpu power (culprit: apt.Cache()), so we do this in a separate
  182.         # thread and then trigger a refiltering of the treeview when done
  183.         res = installablecodecs.getInstallableCodecs()
  184.         gst.log('codec cache loading done, marshalling result into main thread')
  185.         gobject.idle_add(_loading_done_idle_cb, res)
  186.  
  187.     def haveDecoderForCaps(self, decoder_caps):
  188.         caps_string = decoder_caps.to_string()
  189.  
  190.         if caps_string in self.codec_cache:
  191.           return self.codec_cache[caps_string]
  192.  
  193.         registry = gst.registry_get_default()
  194.         features = registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY)
  195.  
  196.         for feature in features:
  197.           # only take into account elements playbin will use
  198.           if feature.get_rank() < gst.RANK_MARGINAL:
  199.             continue
  200.           klass = feature.get_klass()
  201.           # ignore Depayloaders for now
  202.           if klass.find('Demux') >= 0 or \
  203.              klass.find('Decoder') >= 0 or \
  204.              klass.find('Parse') >= 0:
  205.             for pad_template in feature.get_static_pad_templates():
  206.               if pad_template.direction == gst.PAD_SINK:
  207.                 if not pad_template.get_caps().intersect(decoder_caps).is_empty():
  208.                   self.codec_cache[caps_string] = True
  209.                   gst.debug('%s can handle %s' % (feature.get_name(), caps_string))
  210.                   return True
  211.         self.codec_cache[caps_string] = False
  212.         gst.debug('no element found that can handle ' + caps_string)
  213.         return False
  214.  
  215.     ''' do not cache the result of this function '''
  216.     def isInstalledOrInstallable(self, caps_needed):
  217.         if not caps_needed or caps_needed.is_empty() or caps_needed.is_any():
  218.           return False
  219.  
  220.         if self.installable_codecs is None:
  221.           gst.log('database of installable codecs not loaded yet')
  222.           return False
  223.  
  224.         for s in caps_needed:
  225.           if not self.haveDecoderForCaps(gst.Caps(s)):
  226.             gst.debug('no decoder for %s installed' % (s.to_string()))
  227.             if not s.get_name() in self.installable_codecs:
  228.               gst.debug('%s not installable either'  % (s.to_string()))
  229.               return False
  230.  
  231.         return True
  232.  
  233. ###############################################################################
  234.  
  235. '''
  236. UriPlayObject: base class for Brand, Episode, Encoding, Location etc.
  237. '''
  238. class UriPlayObject(object):
  239.     __slots__ = [ 'rdf_attribute_mapping' ]
  240.  
  241.     def __init__(self):
  242.         self.rdf_attribute_mapping = []
  243.  
  244.     def parseProperties(self, conjunctive_graph, graph_obj):
  245.         for rdf_tag, prop_name in self.rdf_attribute_mapping:
  246.           self.__setattr__(prop_name, None)
  247.         for rdf_tag, prop_name in self.rdf_attribute_mapping:
  248.           for match in conjunctive_graph.objects(graph_obj, rdf_tag):
  249.             self.__setattr__(prop_name, match.encode('utf-8'))
  250.             break # we can handle only one value for each property name
  251.  
  252. ###############################################################################
  253.  
  254. '''
  255. Brand: a show/series/group of episodes
  256. '''
  257. class Brand(UriPlayObject):
  258.     __slots__ = [ 'title', 'description', 'episodes', 'genres' ]
  259.  
  260.     def __init__(self):
  261.         self.episodes = []
  262.         self.genres = []
  263.         self.rdf_attribute_mapping = [ ( DC['title'], 'title' ),
  264.                                        ( DC['description'], 'description' ) ]
  265.  
  266.     def parseBrand(self, conjunctive_graph, graph_brand):
  267.         self.parseProperties(conjunctive_graph, graph_brand)
  268.  
  269.         self.episodes = []
  270.         for e in conjunctive_graph.objects(graph_brand, PO['episode']):
  271.           episode = Episode()
  272.           episode.parseEpisode(conjunctive_graph, e)
  273.           self.episodes.append(episode)
  274.  
  275.         self.genres = []
  276.         for match in conjunctive_graph.objects(graph_brand, PO['genre']):
  277.           genre_utf8 = match.encode('utf-8')
  278.           pos = genre_utf8.find('/genres/')
  279.           if pos > 0:
  280.             pos += len('/genres/')
  281.             genre = genre_utf8[pos:]
  282.           else:
  283.             gst.warning('Unexpected genre identifier: ' + genre_utf8)
  284.             genre = 'other'
  285.           if genre not in self.genres:
  286.             self.genres.append(genre)
  287.  
  288.     def hasUsableEpisodes(self):
  289.         for episode in self.episodes:
  290.           if episode.hasUsableEncodings():
  291.             return True
  292.         return False
  293.  
  294.     def getUsableEpisodes(self):
  295.         usable_episodes = []
  296.         for episode in self.episodes:
  297.           if episode.hasUsableEncodings():
  298.             usable_episodes.append(episode)
  299.         return usable_episodes
  300.  
  301. ###############################################################################
  302.  
  303. '''
  304. Episode: a single episode of a Brand (even though we parse the different
  305.          versions, for now we'll just pretend there is only one version and
  306.          map the encodings attribute to the encodings of the first version
  307.          we find, to make things easier)
  308. '''
  309. class Episode(UriPlayObject):
  310.     __slots__ = [ 'title', 'description', 'versions', 'encodings' ]
  311.  
  312.     def __init__(self):
  313.         self.encodings = []
  314.         self.rdf_attribute_mapping = [ ( DC['title'], 'title' ),
  315.                                        ( DC['description'], 'description' ) ]
  316.  
  317.     def parseEpisode(self, conjunctive_graph, graph_episode):
  318.         self.parseProperties(conjunctive_graph, graph_episode)
  319.         self.versions = []
  320.         for v in conjunctive_graph.objects(graph_episode, PO['version']):
  321.           version = EpisodeVersion()
  322.           version.parseVersion(conjunctive_graph, v)
  323.           self.versions.append(version)
  324.           # encodings of episode = encodings of first version of episode
  325.           if not self.encodings:
  326.             self.encodings = version.encodings
  327.  
  328.     def hasUsableEncodings(self):
  329.         for encoding in self.encodings:
  330.           if encoding.isUsable():
  331.             return True
  332.         return False
  333.  
  334.     # TODO: this does not take into account codec quality, highest bitrate wins
  335.     def getBestEncoding(self, connection_speed=0):
  336.         gst.log('connection speed: %d kbit/s' % (connection_speed))
  337.         best_encoding = None
  338.         for encoding in self.encodings:
  339.           if not encoding.isUsable():
  340.             continue
  341.           gst.log('have encoding with bitrate: %d kbit/s' % (encoding.getBitrate()))
  342.           if best_encoding:
  343.             if encoding.getBitrate() > best_encoding.getBitrate():
  344.               if connection_speed <= 0 or encoding.getBitrate() <= connection_speed:
  345.                 best_encoding = encoding
  346.           else:
  347.             best_encoding = encoding
  348.         if best_encoding:
  349.           gst.log('best encoding has bitrate of %d kbit/s' % (best_encoding.getBitrate()))  
  350.         return best_encoding
  351.  
  352.     def getUri(self, connection_speed=0):
  353.         encoding = self.getBestEncoding(connection_speed)
  354.         if encoding:
  355.           location = encoding.getBestLocation()
  356.           if location:
  357.             return location.uri
  358.         return None
  359.  
  360. ###############################################################################
  361.  
  362. '''
  363. EpisodeVersion: a version of an Episode (e.g. UK vs. US or pg-13 vs. 18)
  364. '''
  365. class EpisodeVersion(UriPlayObject):
  366.     __slots__ = [ 'encodings' ]
  367.  
  368.     def __init__(self):
  369.         self.encodings = []
  370.         self.rdf_attribute_mapping = []
  371.  
  372.     def parseVersion(self, conjunctive_graph, graph_version):
  373.         self.parseProperties(conjunctive_graph, graph_version)
  374.         self.encodings = []
  375.         for e in conjunctive_graph.objects(graph_version, PLAY['manifestedAs']):
  376.           encoding = Encoding()
  377.           encoding.parseEncoding(conjunctive_graph, e)
  378.           self.encodings.append(encoding)
  379.  
  380. ###############################################################################
  381.  
  382. '''
  383. Encoding: a specific encoding of an Episode (format/bitrate/size etc.)
  384. '''
  385. class Encoding(UriPlayObject):
  386.     __slots__ = [ 'container_format', 'bitrate', 'size', 'video_codec',
  387.                   'video_bitrate', 'video_fps', 'video_height', 'video_width',
  388.                   'audio_codec', 'audio_bitrate', 'audio_channels',
  389.                   'locations', 'required_caps' ]
  390.  
  391.     def __init__(self):
  392.         self.required_caps = None
  393.         self.rdf_attribute_mapping = [
  394.             ( PLAY['dataContainerFormat'], 'container_format' ),
  395.             ( PLAY['bitRate'], 'bitrate' ),
  396.             ( PLAY['dataSize'], 'size' ),
  397.             ( PLAY['videoCoding'], 'video_codec' ),
  398.             ( PLAY['videoBitrate'], 'video_bitrate' ),
  399.             ( PLAY['videoFrameRate'], 'video_fps' ),
  400.             ( PLAY['videoVerticalSize'], 'video_height' ),
  401.             ( PLAY['videoHorizontalSize'], 'video_width' ),
  402.             ( PLAY['audioCoding'], 'audio_codec' ),
  403.             ( PLAY['audioBitrate'], 'audio_bitrate' ),
  404.             ( PLAY['audioChannels'], 'audio_channels' )]
  405.  
  406.     def parseEncoding(self, conjunctive_graph, graph_encoding):
  407.         self.parseProperties(conjunctive_graph, graph_encoding)
  408.         self.locations = []
  409.         for l in conjunctive_graph.objects(graph_encoding, PLAY['availableAt']):
  410.           location = Location()
  411.           location.parseLocation(conjunctive_graph, l)
  412.           self.locations.append(location)
  413.           self.required_caps = self.postProcessCodecs()
  414.  
  415.     def postProcessCodecs(self):
  416.         required_caps = gst.Caps()
  417.         if self.video_codec:
  418.           self.video_codec = self.video_codec.lower()
  419.           if self.video_codec in video_map:
  420.             required_caps.append(gst.Caps(video_map[self.video_codec]))
  421.           else:
  422.             gst.warning('unmapped video codec ' + self.video_codec)
  423.             return None
  424.         if self.audio_codec:
  425.           self.audio_codec = self.audio_codec.lower()
  426.           if self.audio_codec in audio_map:
  427.             required_caps.append(gst.Caps(audio_map[self.audio_codec]))
  428.           else:
  429.             gst.warning('unmapped audio codec ' + self.audio_codec)
  430.             return None
  431.         if self.container_format:
  432.           self.container_format = self.container_format.lower()
  433.           if self.container_format in container_map:
  434.             required_caps.append(gst.Caps(container_map[self.container_format]))
  435.           else:
  436.             gst.warning('unmapped container format ' + self.container_format)
  437.             return None
  438.  
  439.         if not required_caps.is_empty():
  440.           return required_caps
  441.         else:
  442.           return None
  443.  
  444.     def isUsable(self):
  445.         global codec_cache
  446.  
  447.         if self.required_caps:
  448.           return codec_cache.isInstalledOrInstallable(self.required_caps)
  449.         else:
  450.           return False
  451.  
  452.     def getBitrate(self):
  453.       if not self.bitrate:
  454.         return 0
  455.       return eval(self.bitrate)
  456.  
  457.     def getBestLocation(self):
  458.         locations = self.locations
  459.         random.shuffle(locations)
  460.         for loc in locations:
  461.           if loc.isUsable():
  462.             return loc
  463.         return None
  464.  
  465. ###############################################################################
  466.  
  467. '''
  468. Location: location (URI) of a specific encoding
  469. '''
  470. class Location(UriPlayObject):
  471.     __slots__ = [ 'uri', 'type', 'sub_type', 'is_live' ]
  472.  
  473.     # Note: type, subType and isLive are more often not available than available
  474.     def __init__(self):
  475.         self.rdf_attribute_mapping = [
  476.             ( PLAY['uri'], 'uri' ),
  477.             ( PLAY['transportType'], 'type' ),
  478.             ( PLAY['transportSubType'], 'sub_type' ),
  479.             ( PLAY['transportIsLive'], 'is_live' )]
  480.  
  481.     def parseLocation(self, conjunctive_graph, graph_location):
  482.         self.parseProperties(conjunctive_graph, graph_location)
  483.  
  484.     def isUsable(self):
  485.         if self.uri and self.uri.startswith('http'):
  486.           return True
  487.         return False
  488.  
  489. ###############################################################################
  490.  
  491. '''
  492. ContentPool: downloads rdf file with available content and caches it locally,
  493.              then parses the file and announces new brands and brands where
  494.              the episode listing has changed. The cached file is saved with
  495.              the ETag from the server/gio as part of the filename, so we can
  496.              easily compare the tag to the server's later to check if we have
  497.              to update the file or not (not that ETag here means what we get
  498.              from the gio.FileInfo on the remote uri, and never refers to a
  499.              gio-generated ETag for the local cache file, since those two
  500.              are not comparable)
  501. '''
  502. # TODO:
  503. #  - maybe derive from list store or filtermodel directly?
  504. #  - aggregate codec-cache-loaded and loading-done into loading-done internally,
  505. #    so caller doesn't have to worry about that
  506. class ContentPool(gobject.GObject):
  507.     __slots__ = [ 'cache_dir', 'brands' ]
  508.  
  509.     __gsignals__ = dict(codec_cache_loaded=(gobject.SIGNAL_RUN_LAST, None, ()),
  510.                         progress_message=(gobject.SIGNAL_RUN_LAST, None, (str, )),
  511.                         loading_error=(gobject.SIGNAL_RUN_LAST, None, (str, )),
  512.                         loading_done=(gobject.SIGNAL_RUN_LAST, None, ()))
  513.  
  514.     CACHE_FILE_PREFIX = 'content-'
  515.     CACHE_FILE_SUFFIX = '.cache'
  516.     AVAILABLE_CONTENT_URI = 'http://open.bbc.co.uk/rad/uriplay/availablecontent'
  517.     MAX_CACHE_FILE_AGE = 2*3600  # 2 hours
  518.  
  519.     def __init__(self):
  520.         gobject.GObject.__init__ (self)
  521.  
  522.         self.brands = []
  523.         self.cache_dir = os.path.join(BaseDirectory.xdg_cache_home, 'totem',
  524.                                       'plugins', 'bbc')
  525.         try:
  526.           os.makedirs(self.cache_dir)
  527.           gst.log('created cache directory ' + self.cache_dir)
  528.         except OSError, err:
  529.           if err.errno == errno.EEXIST:
  530.             gst.log('cache directory ' + self.cache_dir + ' already exists')
  531.           else:
  532.             gst.error('failed to create cache directory ' + self.cache_dir +
  533.                       ': ' + err.strerror)
  534.             self.cache_dir = None
  535.  
  536.     def _on_codec_cache_loaded(self, pool):
  537.         self.emit('codec-cache-loaded')
  538.  
  539.     ''' returns True if the given filename refers to one of our cache files '''
  540.     def isCacheFileName(self, filename):
  541.       if not filename.startswith(self.CACHE_FILE_PREFIX):
  542.         return False
  543.       if not filename.endswith(self.CACHE_FILE_SUFFIX):
  544.         return False
  545.       return True
  546.  
  547.     ''' removes all cache files that don't relate to the given etag '''
  548.     def deleteStaleCacheFiles(self, except_etag=None):
  549.         try:
  550.           for fn in dircache.listdir(self.cache_dir):
  551.             if self.isCacheFileName(fn):
  552.               if except_etag == None or fn.find(except_etag) < 0:
  553.                 try:
  554.                   gst.log('deleting stale cache file ' + fn)
  555.                   os.remove(os.path.join(self.cache_dir,fn))
  556.                 except OSError:
  557.                   pass
  558.         except OSError:
  559.           pass
  560.  
  561.     ''' finds the most recent cache file and returns its file name or None'''
  562.     def findMostRecentCacheFile(self):
  563.         best_mtime = 0
  564.         best_name = None
  565.         try:
  566.           gst.log('Looking for cache files in ' + self.cache_dir)
  567.           for fn in dircache.listdir(self.cache_dir):
  568.             if self.isCacheFileName(fn):
  569.               mtime = os.stat(os.path.join(self.cache_dir,fn)).st_mtime
  570.               gst.log('Found cache file %s, mtime %ld' % (fn, long(mtime)))
  571.               if mtime > best_mtime:
  572.                 best_name = fn
  573.                 best_mtime = mtime
  574.         except OSError, err:
  575.           gst.debug("couldn't inspect cache directory %s: %s" % (self.cache_dir, err.strerror))
  576.           return None
  577.  
  578.         if not best_name:
  579.           gst.log('No cache file found')
  580.           return None
  581.  
  582.         return best_name
  583.  
  584.     ''' gets the ETag for the most recent cache file, or None '''
  585.     def getCacheETag(self):
  586.         etag = self.findMostRecentCacheFile()
  587.         if not etag:
  588.           return None
  589.         prefix_len = len(self.CACHE_FILE_PREFIX)
  590.         suffix_len = len(self.CACHE_FILE_SUFFIX)
  591.         etag = etag[prefix_len:-suffix_len]
  592.         gst.log('ETag: ' + etag)
  593.         return etag
  594.  
  595.     ''' makes a full filename from an ETag '''
  596.     def createCacheFileName(self, etag):
  597.         if not etag:
  598.           gst.debug('No ETag, using dummy ETag as fallback')
  599.           etag = '000000-00000-00000000'
  600.         fn = self.CACHE_FILE_PREFIX + etag +  self.CACHE_FILE_SUFFIX
  601.         return os.path.join(self.cache_dir, fn)
  602.  
  603.     def parse_async(self, cache_fn):
  604.         self.emit('progress-message', _("Parsing available content list ..."))
  605.         thread.start_new_thread(self._parsing_thread, (cache_fn, ))
  606.  
  607.     def _parsing_thread(self, cache_fn):
  608.         def _parse_idle_cb(err_msg, brands):
  609.             self.brands = brands
  610.             gst.info('Parsing done: %d brands' % (len(self.brands)))
  611.             if err_msg:
  612.               self.emit('loading-error', err_msg)
  613.             else:
  614.               self.emit('loading-done')
  615.             return False
  616.  
  617.         err_msg = None
  618.         brands = []
  619.         gst.debug('Loading ' + cache_fn)
  620.         store = ConjunctiveGraph()
  621.         try:
  622.           gst.debug('Reading RDF file ...')
  623.           store.load(cache_fn)
  624.           gst.debug('Parsing ' + cache_fn)
  625.           brands = self.parseBrands(store)
  626.         except:
  627.           gst.warning('Problem parsing RDF')
  628.           err_msg = 'Could not parse available content list'
  629.         finally:
  630.           gst.debug('Parsing done, marshalling result into main thread')
  631.           gobject.idle_add(_parse_idle_cb, err_msg, brands)
  632.  
  633.     def _format_size_for_display(self, size):
  634.         if size < 1024:
  635.           return '%d bytes' % size
  636.         if size < 1024*1024:
  637.           return '%.1f kB' % (size / 1024.0)
  638.         else:
  639.           return '%.1f MB' % (size / (1024.0*1024.0))
  640.  
  641.     def load_async(self):
  642.         def _query_done_cb(remote_file, result):
  643.             # mutable container so subfunctions can share access
  644.             # chunks, total_len
  645.             pdata = [ [], 0 ] 
  646.  
  647.             def _read_async_cb(instream, result):
  648.                 try:
  649.                   partial_data = instream.read_finish(result)
  650.                   gst.log('Read partial chunk of %d bytes' % (len(partial_data)))
  651.                   chunks = pdata[0]
  652.                   bytes_read = pdata[1]
  653.                   if len(partial_data) == 0:                  
  654.                     instream.close()
  655.                     outstream = cache_file.create(gio.FILE_CREATE_NONE)
  656.                     for chunk in chunks:
  657.                       outstream.write(chunk)
  658.                     outsize = outstream.query_info('*').get_size()
  659.                     outstream.close()
  660.                     gst.info('Wrote %ld bytes' % (outsize))
  661.                     self.parse_async(cache_fn)
  662.                   else:
  663.                     chunks.append(partial_data)
  664.                     bytes_read += len(partial_data)
  665.                     pdata[0] = chunks
  666.                     pdata[1] = bytes_read
  667.                     instream.read_async(10240, _read_async_cb, io_priority=glib.PRIORITY_LOW-1)
  668.                     self.emit('progress-message',
  669.                               _("Downloading available content list ... ") + '(' +
  670.                               self._format_size_for_display(bytes_read) + ')')
  671.                 except IOError, e:
  672.                   gst.warning('Error downloading ' + self.AVAILABLE_CONTENT_URI)
  673.                   instream.close()
  674.                   try:
  675.                     cache_file.delete()
  676.                   finally:
  677.                     self.emit('loading-error', _("Error downloading available content list"))
  678.  
  679.             # _query_done_cb start:
  680.             gst.log('Query done')
  681.             try:
  682.               remote_info = remote_file.query_info_finish(result)
  683.             except Exception, e:
  684.               # bail out if we can't query, not much point trying to download
  685.               gst.warning('Could not query %s: %s' % (self.AVAILABLE_CONTENT_URI, e.message))
  686.               self.emit('loading-error', _("Could not connect to server"))
  687.               return
  688.  
  689.             gst.log('Got info, querying etag')
  690.             remote_etag = remote_info.get_etag()
  691.             if remote_etag:
  692.               remote_etag = remote_etag.strip('"')
  693.               gst.log('Remote etag: ' + remote_etag)
  694.  
  695.             cache_fn = self.createCacheFileName(remote_etag)
  696.             cache_file = gio.File(cache_fn)
  697.  
  698.             # if file already exists, get size to double-check against server's
  699.             try:
  700.               cache_size = cache_file.query_info('standard::size').get_size()
  701.             except:
  702.               cache_size = 0
  703.             finally:
  704.               if etag and remote_etag and etag == remote_etag:
  705.                 remote_size = remote_info.get_size()
  706.                 if remote_size <= 0 or cache_size == remote_size:
  707.                   gst.log('Cache file is up-to-date, nothing to do')
  708.                   self.parse_async(cache_fn)
  709.                   return
  710.  
  711.             # delete old cache file if it exists
  712.             try:
  713.               cache_file.delete()
  714.             except:
  715.               pass
  716.  
  717.             # FIXME: use gio.File.copy_async() once it's wrapped
  718.             remote_file.read().read_async(10240, _read_async_cb, io_priority=glib.PRIORITY_LOW-1)
  719.             gst.info('copying ' + self.AVAILABLE_CONTENT_URI + ' -> ' + cache_fn)
  720.             self.emit('progress-message', _("Downloading available content list ..."))
  721.             return
  722.  
  723.         # load_async start:
  724.         gst.log('starting loading')
  725.  
  726.         # init global singleton variable codec_cache, if needed
  727.         global codec_cache
  728.  
  729.         if not codec_cache:
  730.           codec_cache = CodecCache()
  731.           codec_cache.connect('loaded', self._on_codec_cache_loaded)
  732.           codec_cache.reload_async()
  733.  
  734.         etag = self.getCacheETag()
  735.         if etag:
  736.           gst.log('Cached etag: ' + etag)
  737.           self.deleteStaleCacheFiles(etag)
  738.           existing_cache_fn = self.createCacheFileName(etag)
  739.           existing_cache_file = gio.File(existing_cache_fn)
  740.           existing_cache_info = existing_cache_file.query_info('time::modified')
  741.           existing_cache_mtime = existing_cache_info.get_modification_time()
  742.           # if the cache file is not older than N minutes/hours/days, then
  743.           # we'll just go ahead and use it instead of downloading a new one,
  744.           # even if it's not perfectly up-to-date.
  745.           # FIXME: UI should have a way to force an update
  746.           secs_since_update = time.time() - existing_cache_mtime
  747.           if secs_since_update >= 0 and secs_since_update < self.MAX_CACHE_FILE_AGE:
  748.             gst.log('Cache file is fairly recent, last updated %f secs ago' % (secs_since_update))
  749.             self.parse_async(existing_cache_fn)
  750.             return
  751.         else:
  752.           gst.log('Cached etag: None')
  753.  
  754.         # CHECKME: what happens if http is not available as protocol?
  755.         remote_file = gio.File(self.AVAILABLE_CONTENT_URI)
  756.         gst.log('Contacting server ' + self.AVAILABLE_CONTENT_URI)
  757.         self.emit('progress-message', _("Connecting to server ..."))
  758.         remote_file.query_info_async(_query_done_cb, '*')
  759.  
  760.     def parseBrands(self, graph):
  761.         brands = []
  762.         for b in graph.subjects(RDF.type, PO['Brand']):
  763.           brand = Brand()
  764.           brand.parseBrand(graph, b)
  765.           brands.append(brand)
  766.           gst.log('[%3d eps] %s %s' % (len(brand.episodes), brand.title, brand.genres))
  767.         return brands
  768.  
  769.     ''' returns array of brands which can potentially be played '''
  770.     def getUsableBrands(self):
  771.         usable_brands = []
  772.         for brand in self.brands:
  773.           if brand.hasUsableEpisodes():
  774.             usable_brands.append(brand)
  775.         return usable_brands
  776.  
  777.  
  778. ###############################################################################
  779.  
  780. class ContentView(gtk.TreeView):
  781.     __slots__ = [ 'pool', 'content_pool_loaded', 'codec_cache_loaded', 'genre_pool' ]
  782.     __gsignals__ = dict(play_episode=
  783.                         (gobject.SIGNAL_RUN_LAST, None,
  784.                          (object,))) # Episode
  785.  
  786.     SORT_ID_1 = 0
  787.  
  788.     def __init__(self):
  789.         gtk.TreeView.__init__ (self)
  790.         self.setupModel()
  791.  
  792.         self.set_headers_visible(False)
  793.  
  794.         self.connect('row-activated', self.onRowActivated)
  795.  
  796.     self.set_property('has-tooltip', True)
  797.     self.connect('query-tooltip', self.onQueryTooltip)
  798.  
  799.         self.set_message(_("Loading ..."))
  800.  
  801.         self.pool = ContentPool()
  802.         self.pool.connect('codec-cache-loaded', self._on_codec_cache_loaded)
  803.         self.pool.connect('progress-message', self._on_content_pool_message)
  804.         self.pool.connect('loading-error', self._on_content_pool_error)
  805.         self.pool.connect('loading-done', self._on_content_pool_loading_done)
  806.         self.codec_cache_loaded = False
  807.         self.content_pool_loaded = False
  808.         self.genre_pool = genres.GenrePool()
  809.  
  810.     def load(self):
  811.         self.pool.load_async()
  812.         gst.log('started loading')
  813.  
  814.     def _on_content_pool_message(self, content_pool, msg):
  815.         self.set_message(msg)
  816.  
  817.     def _on_content_pool_error(self, content_pool, err_msg):
  818.         gst.warning('Failed to load available content: ' + err_msg)
  819.         self.set_message(err_msg)
  820.  
  821.     def _on_content_pool_loading_done(self, content_pool):
  822.         gst.log('content pool loaded')
  823.         self.content_pool_loaded = True
  824.         if self.codec_cache_loaded:
  825.           self.populate()
  826.  
  827.     def _on_codec_cache_loaded(self, content_pool):
  828.         gst.log('codec cache loaded, refilter')
  829.         self.codec_cache_loaded = True
  830.         #self.filter.refilter() FIXME: we don't filter at the moment
  831.         if self.content_pool_loaded:
  832.           self.populate()
  833.  
  834.     def populate_add_genre(self, genre, parent_iter):
  835.         _iter = self.store.append(parent_iter, [None, None, None, genre])
  836.         for child_genre in genre.children:
  837.           self.populate_add_genre(child_genre, _iter)
  838.         for brand in genre.brands:
  839.           brand_iter = self.store.append(_iter, [brand, None, None, None])
  840.           for ep in brand.episodes:
  841.             self.store.append(brand_iter, [brand, ep, None, None])
  842.         return _iter
  843.  
  844.     def populate(self):
  845.         gst.log('populating treeview')
  846.  
  847.         brands = self.pool.getUsableBrands()
  848.         gst.info('%d brands with usable episodes/encodings' % (len(brands)))
  849.  
  850.         # build genre tree in memory and add brands to genre objects
  851.         self.genre_pool.clear()
  852.         for brand in brands:
  853.           for genre_shortref in brand.genres:
  854.             genre = self.genre_pool.get_genre(genre_shortref)
  855.             genre.add_brand(brand)
  856.  
  857.         # add everything to the list store
  858.         self.store.clear()
  859.         toplevel_iters = []
  860.         for toplevel_genre in self.genre_pool.get_toplevel_genres():
  861.           _iter = self.populate_add_genre(toplevel_genre, None)          
  862.           toplevel_iters.append(_iter)
  863.           
  864.         # now make all this visible (view might be showing model with message)
  865.         self.set_model(self.filter)
  866.  
  867.         # expand top-level categories
  868.         for _iter in toplevel_iters:
  869.           path = self.store.get_path(_iter)
  870.           self.expand_row(path, False)
  871.  
  872.     def get_brand_tooltip(self, brand):
  873.       if not brand or not brand.description:
  874.         return None
  875.       return '<b>%s</b>\n<i>%s</i>' % (gobject.markup_escape_text(brand.title),
  876.                                         gobject.markup_escape_text(brand.description))
  877.  
  878.     def get_episode_tooltip(self, brand, episode):
  879.       if not episode or not episode.description:
  880.         return None
  881.       return '<b>%s</b>\n<b><small>%s</small></b>\n<i>%s</i>' % (gobject.markup_escape_text(brand.title),
  882.                                                                  gobject.markup_escape_text(episode.title),
  883.                                                                  gobject.markup_escape_text(episode.description))
  884.  
  885.     def onQueryTooltip(self, view, x, y, keyboard_tip, tip):
  886.       try:
  887.         model, path, _iter = self.get_tooltip_context(x, y, keyboard_tip)
  888.       except:
  889.         return False # probably no content yet
  890.  
  891.       brand, episode, msg, genre = model.get(_iter, 0, 1, 2, 3)
  892.       if msg or genre:
  893.         return False
  894.       if brand and not episode:
  895.         markup = self.get_brand_tooltip(brand)
  896.       elif brand and episode:
  897.         markup = self.get_episode_tooltip(brand, episode)
  898.       else:
  899.         markup = None
  900.       if markup:
  901.         tip.set_markup(markup)
  902.       else:
  903.         tip.set_text(_("No details available"))
  904.       return True
  905.  
  906.     def onRowActivated(self, view, path, col):
  907.         model = self.get_model()
  908.         if model:
  909.           _iter = model.get_iter(path)
  910.           brand, episode = self.get_model().get(_iter, 0, 1)
  911.           if episode:
  912.             self.emit('play-episode', episode)
  913.  
  914.     def renderGenreCell(self, column, renderer, model, _iter, genre):
  915.         markup = '<b>%s</b>' % \
  916.                  (gobject.markup_escape_text(genre.label))
  917.         renderer.set_property('markup', markup)
  918.  
  919.     def renderBrandCell(self, column, renderer, model, _iter, brand):
  920.         markup = '<b><small>%s <span color="LightGray">(%d)</span></small></b>' % \
  921.                  (gobject.markup_escape_text(brand.title), len(brand.episodes))
  922.         renderer.set_property('markup', markup)
  923.  
  924.     def renderEpisodeCell(self, column, renderer, model, _iter, brand, episode):
  925.         markup = '<span><small>%s</small></span>' % (gobject.markup_escape_text(episode.title))
  926.         renderer.set_property('markup', markup)
  927.  
  928.     def renderMessageCell(self, column, renderer, model, _iter, msg):
  929.         markup = '<i>%s</i>' % (gobject.markup_escape_text(msg))
  930.         renderer.set_property('markup', markup)
  931.         
  932.     def renderCell(self, column, renderer, model, _iter):
  933.         brand, episode, msg, genre = model.get(_iter, 0, 1, 2, 3)
  934.         if msg:
  935.           self.renderMessageCell(column, renderer, model, _iter, msg)
  936.         elif genre:
  937.           self.renderGenreCell(column, renderer, model, _iter, genre)
  938.         elif not episode:
  939.           self.renderBrandCell(column, renderer, model, _iter, brand)
  940.         else:
  941.           self.renderEpisodeCell(column, renderer, model, _iter, brand, episode)
  942.  
  943.     # there must be a more elegant way to do this in python
  944.     def sortFunc(self, model, iter1, iter2):
  945.         brand1, episode1, genre1 = model.get(iter1, 0, 1, 3)
  946.         brand2, episode2, genre2 = model.get(iter2, 0, 1, 3)
  947.  
  948.         # genres are sorted by genre.sort_rank
  949.         if genre1 and genre2:
  950.           if genre1.sort_rank != genre2.sort_rank:
  951.             return genre1.sort_rank - genre2.sort_rank
  952.           else:
  953.             s1 = genre1.label
  954.             s2 = genre2.label
  955.  
  956.         # genre always comes before any other siblings (like brands or episodes)
  957.         elif genre1:
  958.           return -1
  959.         elif genre2:
  960.           return 1
  961.  
  962.         # brands and episodes are sorted alphabetically by title
  963.         elif not episode1 or not episode2:
  964.           s1 = brand1.title
  965.           s2 = brand2.title
  966.         elif episode1 and episode2:
  967.           s1 = episode1.title
  968.           s2 = episode2.title
  969.         else:
  970.           gst.warning('should not be reached (should be genre label comparison)')
  971.  
  972.         # string comparison
  973.         if s1 == s2:
  974.           return 0
  975.         elif s1 > s2:
  976.           return 1
  977.         else:
  978.           return -1
  979.  
  980.     def set_message(self, msg):
  981.         self.msg_store.clear()
  982.         self.msg_store.append(None, [None, None, msg, None])
  983.         self.set_model(self.msg_store)
  984.         gst.log('set message "' + msg + '"')
  985.  
  986.     def setupModel(self):
  987.         # columns: Brand, Episode, message string, Genre
  988.         self.msg_store = gtk.TreeStore(object, object, str, object)
  989.         self.store = gtk.TreeStore(object, object, str, object)
  990.         self.filter = self.store.filter_new()
  991.  
  992.         column = gtk.TreeViewColumn()
  993.         renderer = gtk.CellRendererText()
  994.         renderer.set_property('ellipsize', pango.ELLIPSIZE_END)
  995.         column.pack_start(renderer, expand=True)
  996.         column.set_cell_data_func(renderer, self.renderCell)
  997.         self.append_column(column)
  998.         self.store.set_sort_func(self.SORT_ID_1, self.sortFunc)
  999.         self.store.set_sort_column_id(self.SORT_ID_1, gtk.SORT_ASCENDING)
  1000.  
  1001. if __name__ == "__main__":
  1002.     # ensure the caps strings in the container/video/audio map are parsable
  1003.     for cs in video_map:
  1004.       caps = gst.Caps(video_map[cs])
  1005.     for cs in audio_map:
  1006.       caps = gst.Caps(audio_map[cs])
  1007.     for cs in container_map:
  1008.       caps = gst.Caps(container_map[cs])
  1009.     # test window
  1010.     window = gtk.Window()
  1011.     scrollwin = gtk.ScrolledWindow()
  1012.     scrollwin.set_policy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)
  1013.     window.add(scrollwin)
  1014.     view = ContentView()
  1015.     view.load()
  1016.     scrollwin.add(view)
  1017.     window.show_all()
  1018.     gtk.main()
  1019.  
  1020.